Browse Source

Replace custom Core Data publisher with history-sourced publisher

Add CoreDataStack.entityChangePublisher, fed from the persistent history
transactions that mergePersistentHistoryChanges already fetches. Unlike
the previous changedObjectsOnManagedObjectContextDidSavePublisher (which
observed NSManagedObjectContextDidSave), this also covers batch inserts/
deletes and cross-process changes — it is the single, history-sourced
change feed.

Migrate the remaining "ping" services (LiveActivity, Calendar, Apple
Watch, Garmin, ContactImage, UserNotifications, IOBService) to it; the
call sites are unchanged apart from the source, filteredByEntityName
still applies.

Strip CoreDataObserver.swift down to just the filteredByEntityName
operator; the changedObjects publisher, CoreDataChangeTypes and the
Notification objectID helpers are removed.
Marvin Polscheit 2 weeks ago
parent
commit
bccdabd3c7

+ 6 - 82
Model/CoreDataObserver.swift

@@ -2,79 +2,20 @@ import Combine
 import CoreData
 import Foundation
 
-/// Represents the types of Core Data changes that can be observed
-/// Use as an option set to specify which types of changes to monitor
-public struct CoreDataChangeTypes: OptionSet {
-    /// The raw integer value used to store the option set bits
-    public let rawValue: Int
-
-    /// Required initializer for OptionSet conformance
-    public init(rawValue: Int) {
-        self.rawValue = rawValue
-    }
-
-    /// Represents newly created/inserted objects in Core Data
-    /// Binary: 001 (1 << 0)
-    public static let inserted = CoreDataChangeTypes(rawValue: 1 << 0)
-
-    /// Represents modified/updated objects in Core Data
-    /// Binary: 010 (1 << 1)
-    public static let updated = CoreDataChangeTypes(rawValue: 1 << 1)
-
-    /// Represents removed/deleted objects in Core Data
-    /// Binary: 100 (1 << 2)
-    public static let deleted = CoreDataChangeTypes(rawValue: 1 << 2)
-
-    /// Convenience option that includes all possible change types
-    /// This combines inserted, updated, and deleted into a single option
-    public static let all: CoreDataChangeTypes = [.inserted, .updated, .deleted]
-}
-
-/// Creates a publisher that emits sets of NSManagedObjectIDs when Core Data changes occur
-/// - Parameter changeTypes: The types of changes to observe (defaults to .all)
-/// - Returns: A publisher that emits Sets of NSManagedObjectIDs for the specified change types
-func changedObjectsOnManagedObjectContextDidSavePublisher(
-    observing changeTypes: CoreDataChangeTypes = .all
-) -> some Publisher<Set<NSManagedObjectID>, Never> {
-    Foundation.NotificationCenter.default
-        .publisher(for: .NSManagedObjectContextDidSave)
-        .compactMap { notification -> Set<NSManagedObjectID>? in
-
-            var objectIDs = Set<NSManagedObjectID>()
-
-            // Process inserted objects if requested
-            if changeTypes.contains(.inserted) {
-                objectIDs.formUnion(notification.insertedObjectIDs)
-            }
-
-            // Process updated objects if requested
-            if changeTypes.contains(.updated) {
-                objectIDs.formUnion(notification.updatedObjectIDs)
-            }
-
-            // Process deleted objects if requested
-            if changeTypes.contains(.deleted) {
-                objectIDs.formUnion(notification.deletedObjectIDs)
-            }
-
-            // Only emit non-empty sets
-            return objectIDs.isEmpty ? nil : objectIDs
-        }
-}
+// The app-wide Core Data change feed now lives on `CoreDataStack.entityChangePublisher`, which is
+// sourced from persistent history (and therefore also covers batch operations and cross-process
+// changes). This file only keeps the `filteredByEntityName` operator used by its subscribers.
 
 extension Publisher where Output == Set<NSManagedObjectID> {
-    /// Filters Core Data changes by entity name
-    ///
-    /// This method allows filtering Core Data changes by entity name.
+    /// Filters Core Data changes by entity name.
     ///
     /// Example usage:
     /// ```swift
     /// // Filter changes for "GlucoseStored" entity
-    /// publisher.filteredByEntityName("GlucoseStored")
+    /// CoreDataStack.shared.entityChangePublisher.filteredByEntityName("GlucoseStored")
     /// ```
     ///
-    /// - Parameters:
-    ///   - name: The name of the Core Data entity to filter for
+    /// - Parameter name: The name of the Core Data entity to filter for
     /// - Returns: A publisher emitting filtered sets of NSManagedObjectIDs
     func filteredByEntityName(
         _ name: String
@@ -90,20 +31,3 @@ extension Publisher where Output == Set<NSManagedObjectID> {
         }
     }
 }
-
-extension Notification {
-    var insertedObjectIDs: Set<NSManagedObjectID> {
-        guard let objects = userInfo?[NSInsertedObjectsKey] as? Set<NSManagedObject> else { return [] }
-        return Set(objects.lazy.map(\.objectID))
-    }
-
-    var updatedObjectIDs: Set<NSManagedObjectID> {
-        guard let objects = userInfo?[NSUpdatedObjectsKey] as? Set<NSManagedObject> else { return [] }
-        return Set(objects.lazy.map(\.objectID))
-    }
-
-    var deletedObjectIDs: Set<NSManagedObjectID> {
-        guard let objects = userInfo?[NSDeletedObjectsKey] as? Set<NSManagedObject> else { return [] }
-        return Set(objects.lazy.map(\.objectID))
-    }
-}

+ 17 - 0
Model/CoreDataStack.swift

@@ -1,3 +1,4 @@
+import Combine
 import CoreData
 import Foundation
 import OSLog
@@ -14,6 +15,15 @@ class CoreDataStack: ObservableObject {
     private let maxRetries = 3
     private let initializationCoordinator = CoreDataInitializationCoordinator()
 
+    /// Emits the set of changed object IDs from each batch of persistent history transactions.
+    /// Sourced from persistent history, so — unlike `NSManagedObjectContextDidSave` — it also
+    /// covers `NSBatchInsertRequest`/`NSBatchDeleteRequest` and cross-process changes. App-side
+    /// observers subscribe via `entityChangePublisher` and filter with `filteredByEntityName(_:)`.
+    private let entityChangeSubject = PassthroughSubject<Set<NSManagedObjectID>, Never>()
+    var entityChangePublisher: AnyPublisher<Set<NSManagedObjectID>, Never> {
+        entityChangeSubject.eraseToAnyPublisher()
+    }
+
     private init(inMemory: Bool = false) {
         self.inMemory = inMemory
 
@@ -144,6 +154,13 @@ class CoreDataStack: ObservableObject {
                 self.lastToken = transaction.token
             }
         }
+
+        // Notify app-side observers (services) about which objects changed. This history-sourced
+        // change feed replaces the hand-rolled changedObjectsOnManagedObjectContextDidSavePublisher.
+        let changedObjectIDs = Set(history.flatMap { $0.changes ?? [] }.map(\.changedObjectID))
+        if !changedObjectIDs.isEmpty {
+            entityChangeSubject.send(changedObjectIDs)
+        }
     }
 
     // Clean old Persistent History

+ 1 - 1
Trio/Sources/Services/Calendar/CalendarManager.swift

@@ -65,7 +65,7 @@ final class BaseCalendarManager: CalendarManager, Injectable {
         setupCurrentCalendar()
 
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .share()
                 .eraseToAnyPublisher()

+ 1 - 1
Trio/Sources/Services/ContactImage/ContactImageManager.swift

@@ -55,7 +55,7 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable {
         injectServices(resolver)
         units = settingsManager.settings.units
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .share()
                 .eraseToAnyPublisher()

+ 1 - 1
Trio/Sources/Services/IOB/IOBService.swift

@@ -38,7 +38,7 @@ final class BaseIOBService: IOBService, Injectable {
     init(resolver: Resolver) {
         injectServices(resolver)
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .share()
                 .eraseToAnyPublisher()

+ 1 - 1
Trio/Sources/Services/LiveActivity/LiveActivityManager.swift

@@ -79,7 +79,7 @@ final class LiveActivityData: ObservableObject {
     /// - Parameter resolver: The dependency injection resolver.
     init(resolver: Resolver) {
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .share()
                 .eraseToAnyPublisher()

+ 1 - 1
Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift

@@ -91,7 +91,7 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         injectServices(resolver)
 
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .share()
                 .eraseToAnyPublisher()

+ 1 - 1
Trio/Sources/Services/WatchManager/AppleWatchManager.swift

@@ -60,7 +60,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
 
         // Observer for OrefDetermination and adjustments
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .share()
                 .eraseToAnyPublisher()

+ 1 - 1
Trio/Sources/Services/WatchManager/GarminManager.swift

@@ -193,7 +193,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
         broadcaster.register(SettingsObserver.self, observer: self)
 
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .share()
                 .eraseToAnyPublisher()